import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useState } from 'react'; import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Circle, Line, Polygon, Text as SvgText } from 'react-native-svg'; import { useJobDetail } from '../../src/hooks'; import { buildJobShareText } from '../../src/api'; import { shareText } from '../../src/lib/shareFile'; import { colors } from '../../src/theme'; import { formatThousands } from '../../src/utils'; import type { JobDimensionScore } from '../../src/types'; /* ===================== 雷达图(6 维度,SVG 绘制) ===================== */ /** 六边形雷达图:同心网格 + 轴线 + 数据多边形 + 维度标签 */ function RadarChart({ dimensions, size = 160, }: { dimensions: JobDimensionScore[]; size?: number; }) { const cx = size / 2; const cy = size / 2; const count = dimensions.length; // 数据/网格最大半径(预留标签空间) const R = size / 2 - 36; const labelR = R + 16; const levels = [0.25, 0.5, 0.75, 1]; const angleFor = (i: number) => (-90 + i * (360 / count)) * (Math.PI / 180); const pointFor = (i: number, ratio: number) => ({ x: cx + R * ratio * Math.cos(angleFor(i)), y: cy + R * ratio * Math.sin(angleFor(i)), }); const polygonPoints = (ratio: number) => dimensions .map((_, i) => { const p = pointFor(i, ratio); return `${p.x},${p.y}`; }) .join(' '); const dataPoints = dimensions.map((d, i) => pointFor(i, d.value / 100)); return ( {/* 同心网格 */} {levels.map((ratio) => ( ))} {/* 轴线 */} {dimensions.map((_, i) => { const p = pointFor(i, 1); return ( ); })} {/* 数据多边形 */} `${p.x},${p.y}`).join(' ')} fill={colors.primary} fillOpacity={0.15} stroke={colors.primary} strokeWidth={2} /> {/* 数据点 */} {dataPoints.map((p, i) => ( ))} {/* 维度标签 */} {dimensions.map((d, i) => { const lx = cx + labelR * Math.cos(angleFor(i)); const ly = cy + labelR * Math.sin(angleFor(i)); return ( {d.name} ); })} ); } /* ===================== 子组件 ===================== */ /** 顶部导航:返回 + 标题 + 分享(onShare 缺省时不渲染分享按钮,避免留下点了没反应的入口) */ function Header({ onBack, onShare }: { onBack: () => void; onShare?: () => void }) { return ( 岗位详情 {onShare ? ( ) : ( )} ); } /** 分区标题:竖条装饰 + 标题 + 可选右侧链接 */ function SectionTitle({ title, link, onLink, }: { title: string; link?: string; onLink?: () => void; }) { return ( {title} {link ? ( {link} > ) : null} ); } /* ===================== 页面 ===================== */ export default function JobDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const { detail, overallScore, salaryData, reviews, loading, error, refresh, publishReview } = useJobDetail(id); const [comment, setComment] = useState(''); const [publishing, setPublishing] = useState(false); /** * 分享岗位:把当前岗位拼成文本经系统分享面板发出。 * `ok` 只代表面板已拉起,不能据此提示「分享成功」,只在失败时说明原因(见 lib/shareFile)。 */ const handleShare = async () => { if (!detail) return; const outcome = await shareText( buildJobShareText(detail, overallScore, salaryData, reviews.length), `${detail.positionName}|${detail.companyName}`, ); if (!outcome.ok) Alert.alert('无法分享', outcome.message); }; // 发布评价:空内容拦截,成功后清空输入框(列表由 hook 自动刷新) const handlePublish = async () => { const text = comment.trim(); if (!text) { Alert.alert('提示', '请先输入你的评价内容'); return; } setPublishing(true); try { await publishReview(text); setComment(''); } catch (e) { Alert.alert('发布失败', (e as Error).message || '请稍后重试'); } finally { setPublishing(false); } }; // 错误态 if (error) { return (
router.back()} /> 加载失败,请重试 重试 ); } // 加载态 if (loading || !detail || !overallScore || !salaryData) { return (
router.back()} /> ); } return (
router.back()} onShare={handleShare} /> {/* 1. 岗位基本信息 */} {detail.positionName} {detail.status} 浏览{formatThousands(detail.views)} {detail.salaryMin}-{detail.salaryMax}K · {detail.salaryMonths}薪 {detail.companyName} · {detail.industry} · {detail.companySize} {detail.companyLocation} · {detail.experience} · {detail.education} {detail.benefits.map((item) => ( {item} ))} {/* 2. 岗位综合评分 */} {/* 「评分说明」入口已摘除:项目没有评分规则说明页 */} {overallScore.totalScore} 分 {overallScore.level} {overallScore.rank} router.push('/jobs/compare')} hitSlop={6}> 加入对比 {/* 3. 真实薪资区间 */} {/* 「全部」入口已摘除:下面这张卡已经把 4 档经验区间列全了,点进去没有更多内容 */} {salaryData.ranges.map((range, index) => ( {range.experience} {range.min}-{range.max}K ))} {salaryData.dataSource} {/* 4. 员工评价 */} {reviews.length === 0 && ( /* 标题是常驻的,没有这一块时下方会是一片空白,看上去像加载坏了 */ 暂无员工评价 成为第一个分享这家公司真实情况的人 )} {reviews.map((review) => ( {review.userName} {review.userRole} {review.time} {review.content} {review.tags.map((tag) => ( {tag} ))} ))} {/* 5. 评论输入框(固定底部) */} {publishing ? ( ) : ( 发布 )} ); } const styles = StyleSheet.create({ safeArea: { flex: 1, backgroundColor: colors.pageBg, }, stateWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', }, stateText: { marginTop: 12, fontSize: 14, color: colors.textSecondary, }, retryBtn: { marginTop: 16, paddingHorizontal: 24, paddingVertical: 8, borderRadius: 18, backgroundColor: colors.primaryBtn, }, retryText: { fontSize: 13, fontWeight: '600', color: colors.white, }, /* ---- 顶部导航 ---- */ header: { height: 48, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary, }, /** * 头部按钮:返回靠左、分享靠右。 * 原先两者共用一条**只写了 `top`、没写 `left`/`right`** 的 absolute 样式, * 在 `justifyContent: 'center'` 的父容器里落到同一处彼此重叠 —— 返回键与分享键 * 都不在用户预期位置(分享键实测点不到)。absolute 必须成对写清水平锚点。 */ headerBtn: { position: 'absolute', top: 0, left: 0, width: 48, height: 48, alignItems: 'center', justifyContent: 'center', }, headerBtnRight: { position: 'absolute', top: 0, right: 0, width: 48, height: 48, alignItems: 'center', justifyContent: 'center', }, scroll: { flex: 1, }, content: { paddingHorizontal: 20, paddingBottom: 24, }, card: { backgroundColor: colors.white, borderRadius: 8, padding: 16, marginBottom: 16, }, /* ---- 空态 ---- */ emptyCard: { backgroundColor: colors.white, borderRadius: 8, paddingVertical: 32, paddingHorizontal: 16, marginBottom: 16, alignItems: 'center', gap: 8, }, emptyText: { fontSize: 14, fontWeight: '600', color: colors.textSecondary, }, emptyHint: { fontSize: 12, color: colors.textHint, textAlign: 'center', }, /* ---- 基本信息 ---- */ basicTop: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', }, positionName: { flex: 1, fontSize: 22, fontWeight: '700', color: colors.textPrimary, }, statusCol: { alignItems: 'flex-end', marginLeft: 12, }, statusTag: { backgroundColor: colors.successLight, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10, }, statusText: { fontSize: 12, fontWeight: '600', color: colors.successText, }, viewsText: { marginTop: 6, fontSize: 12, color: colors.textHint, }, salary: { marginTop: 14, fontSize: 20, fontWeight: '700', color: colors.primary, }, metaText: { marginTop: 8, fontSize: 13, color: colors.textSecondary, }, benefits: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 12, }, benefitTag: { backgroundColor: colors.primaryLight, borderWidth: 1, borderColor: colors.primaryBorder, borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4, marginRight: 8, marginBottom: 8, }, benefitText: { fontSize: 12, color: colors.primary, }, /* ---- 分区标题 ---- */ sectionHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, }, sectionBar: { width: 4, height: 16, borderRadius: 2, backgroundColor: colors.primary, marginRight: 8, }, sectionTitle: { flex: 1, fontSize: 17, fontWeight: '700', color: colors.textPrimary, }, sectionLinkBtn: { paddingLeft: 8, }, sectionLink: { fontSize: 13, color: colors.primary, }, /* ---- 综合评分 ---- */ scoreRow: { flexDirection: 'row', alignItems: 'center', }, scoreInfo: { flex: 1, alignItems: 'flex-start', marginLeft: 8, }, scoreNumberRow: { flexDirection: 'row', alignItems: 'baseline', }, scoreNumber: { fontSize: 40, fontWeight: '700', color: colors.primary, }, scoreUnit: { fontSize: 14, fontWeight: '600', color: colors.primary, }, levelTag: { marginTop: 4, backgroundColor: colors.successLight, paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, }, levelTagText: { fontSize: 13, fontWeight: '600', color: colors.successText, }, scoreRank: { marginTop: 8, fontSize: 12, color: colors.textSecondary, }, compareBtn: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', marginTop: 16, }, compareText: { marginLeft: 4, fontSize: 14, fontWeight: '600', color: colors.primary, }, /* ---- 薪资区间 ---- */ salaryRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, salaryRowLast: { borderBottomWidth: 0, }, salaryExp: { fontSize: 14, color: colors.textSecondary, }, salaryRange: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, }, salarySource: { marginTop: 12, fontSize: 12, color: colors.textHint, }, /* ---- 员工评价 ---- */ reviewHeader: { flexDirection: 'row', alignItems: 'center', }, reviewName: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, }, reviewRole: { marginLeft: 8, fontSize: 12, color: colors.textSecondary, }, reviewTime: { flex: 1, textAlign: 'right', fontSize: 12, color: colors.textHint, }, reviewContent: { marginTop: 10, fontSize: 13, lineHeight: 20, color: colors.textPrimary, }, reviewTags: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 10, }, reviewTag: { backgroundColor: colors.pageBg, borderWidth: 1, borderColor: colors.border, borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4, marginRight: 8, marginBottom: 8, }, reviewTagText: { fontSize: 12, color: colors.textSecondary, }, /* ---- 评论输入框 ---- */ commentBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, backgroundColor: colors.white, borderTopWidth: 1, borderTopColor: colors.borderLight, }, commentInput: { flex: 1, height: 40, borderRadius: 20, backgroundColor: colors.pageBg, paddingHorizontal: 16, fontSize: 14, color: colors.textPrimary, }, publishBtn: { marginLeft: 12, paddingHorizontal: 18, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.primaryBtn, }, publishBtnDisabled: { opacity: 0.6, }, publishText: { fontSize: 14, fontWeight: '600', color: colors.white, }, });